#!/usr/bin/env python3
"""Answers one private ask, or carries out one action the operator clicked on a note, in its own short Claude
Code session, in parallel with the listening agent.
Aux runs this the moment it writes the ask or the action is clicked:

  aux-ask '<record json>'              # {"at","call","text","duration_s","source"} as Aux wrote it; an action has
                                       # "source":"action" with "note", "index", "label", and the prompt as "text"
  aux-ask --text "…" [--call <id>]     # by hand

1. Writes status.json at once; the notebook's status line shows the ask in flight (no ack note).
2. Builds $AUX_HOME/asks/<id>/prompt.md: the ask or the action, earlier asks on this call and their answers, who
   is on the call, the last 15 minutes of speech, me.md, home.md and the participants' profiles.
3. Runs `claude -p … "/answer-ask <dir>"` (the answer-ask skill) and records its state in status.json.
   At most ASK_SLOTS sessions run at once; later asks queue here until one exits. The session writes with `aux`.
4. A dead session is never silence: a "Couldn't answer" note for an ask, a failed line on the note for an action.
"""
import argparse
import json
import os
import re
import secrets
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

# Inside an app bundle (Tuple.app/Contents/Helpers/Tuple Aux.app) a __pycache__ write is macOS's
# "python3 was prevented from modifying apps" prompt; the import never needs one.
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent))
import aux_context as ctx  # noqa: E402

ASKS = ctx.AUX / "asks"
ASK_SLOTS = 3
MODEL = "sonnet"   # Sonnet 5.5, like the call agent (Ben, 2026-11-11)
MAX_TURNS = 20
PROJECT = ctx.project()
AUXCLI = Path(__file__).resolve().parent / "aux"
SPEECH_MINUTES = 15


def now_local():
    return datetime.now().astimezone()


def set_status(d, **kv):
    path = d / "status.json"
    try:
        cur = json.loads(path.read_text())
    except (OSError, ValueError):
        cur = {}
    cur.update(kv)
    path.write_text(json.dumps(cur) + "\n")


def aux(call, *args):
    """Runs `aux` for this call; the output, or None when it failed."""
    code, out, _ = ctx.run([str(AUXCLI), "--call", call or "asks", *args], timeout=10)
    return out if code == 0 else None


def log_events(call):
    try:
        lines = (ctx.AUX / "calls" / (call or "asks") / "notebook.jsonl").read_text().splitlines()
    except OSError:
        return []
    out = []
    for line in lines:
        try:
            out.append(json.loads(line))
        except ValueError:
            continue
    return out


def earlier_asks(call, exclude):
    """This call's earlier asks (from asks.ndjson) with the title of the answer card that names them, if any."""
    out = []
    try:
        lines = (ctx.AUX / "asks.ndjson").read_text().splitlines()
    except OSError:
        return out
    for line in lines:
        try:
            r = json.loads(line)
        except ValueError:
            continue
        if r.get("text") == exclude.get("text") and r.get("at") == exclude.get("at"):
            continue
        if call and r.get("call") and r.get("call") != call:
            continue
        if not call and r.get("call"):
            continue
        answer = None
        for d in ASKS.glob("*"):
            try:
                rec = json.loads((d / "record.json").read_text())
                st = json.loads((d / "status.json").read_text())
            except (OSError, ValueError):
                continue
            if rec.get("at") == r.get("at") and st.get("answer_title"):
                answer = st["answer_title"]
        t = ctx._parse(r.get("at"))
        stamp = t.astimezone().strftime("%H:%M") if t else "--:--"
        out.append(f"- {stamp} \"{r.get('text', '').strip()}\" → " + (f"answered: \"{answer}\"" if answer else "no answer yet"))
    return out[-8:]


def build_prompt(d, record, env, cli, call):
    recs = ctx.capture(cli, call) if call else []
    people = ctx.participants(cli, call, recs) if call else {}
    me = ctx.read(ctx.WIKI / "me.md") or "(missing)"
    first = "the operator"
    m = re.search(r"^#\s+(\S+)", me, re.M)
    if m:
        first = m.group(1)
    t = ctx._parse(record.get("at")) or datetime.now(timezone.utc)
    if record.get("source") == "action":
        head = [f"# An action {first} approved — {t.astimezone().strftime('%H:%M')}, call {call or 'no call'} ({env})\n",
                "## The action the operator approved\n"
                f"They clicked \"{record.get('label', '')}\" on the note \"{record.get('note_title', '')}\" in their notebook.\n\n"
                f"- note id: `{record.get('note')}`\n- action index: `{record.get('index')}`\n\n"
                "What to do (follow the answer-ask skill, section 5):\n\n" + record.get("text", "").strip() + "\n"]
    else:
        head = [f"# Private ask from {first} — {t.astimezone().strftime('%H:%M')}, call {call or 'no call'} ({env})\n",
                "## The ask\n" + record.get("text", "").strip() + "\n"]
    parts = [*head,
             "## Earlier asks on this call (oldest first)\n" + ("\n".join(earlier_asks(call, record)) or "- none") + "\n",
             "## Who is on the call\n" + ("\n".join(f"- {n} (user_id {u})" for u, n in people.items()) or "- unknown (no live call)") + "\n",
             f"## Last {SPEECH_MINUTES} minutes of the call (speech only)\n" + ("\n".join(ctx.recent_speech(recs, SPEECH_MINUTES)) or "(nothing captured)") + "\n",
             "## Operator (me.md)\n" + me + "\n",
             "## home.md\n" + (ctx.read(ctx.WIKI / "home.md") or "(missing)") + "\n"]
    for uid, name in people.items():
        p = ctx.profile(uid, name)
        if p and p.name != "me.md":
            parts.append(f"## Profile: {name} ({p.relative_to(ctx.WIKI)})\n" + (ctx.read(p, limit=6000) or "") + "\n")
    (d / "prompt.md").write_text("\n".join(parts))


def answered_note(call, since):
    """Fallback when the session wrote its note but not answer.title: the newest note written since the ask."""
    for e in reversed(log_events(call)):
        if e.get("type") == "note" and e.get("at", 0) >= since * 1000 and not e.get("title", "").startswith("Couldn't answer: "):
            return e.get("title")
    return None


def action_report(call, note, index, since):
    """What the session reported for the action, if it did: (state, text)."""
    for e in reversed(log_events(call)):
        if e.get("type") == "action" and e.get("note") == note and e.get("index") == index \
                and e.get("state") in ("done", "failed") and e.get("at", 0) >= since * 1000:
            return e["state"], e.get("text") or ""
    return None


def running():
    n = 0
    for d in ASKS.glob("*"):
        try:
            st = json.loads((d / "status.json").read_text())
        except (OSError, ValueError):
            continue
        if st.get("state") == "running" and st.get("pid"):
            try:
                os.kill(st["pid"], 0)
                n += 1
            except OSError:
                pass
    return n


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("record", nargs="?", help="the ask as JSON (what Aux wrote)")
    ap.add_argument("--text")
    ap.add_argument("--call")
    ap.add_argument("--model", default=MODEL)
    effort_file = ctx.AUX / "ask-effort"
    ap.add_argument("--effort", default=effort_file.read_text().strip() if effort_file.exists() else "medium")
    a = ap.parse_args()
    if a.record:
        record = json.loads(a.record)
    elif a.text:
        record = {"at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "call": a.call or "",
                  "text": a.text, "duration_s": 0, "source": "manual"}
    else:
        ap.error("give the record JSON or --text")
    text = record.get("text", "").strip()
    if not text:
        return 2

    ask_id = now_local().strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
    d = ASKS / ask_id
    d.mkdir(parents=True, exist_ok=True)
    (d / "record.json").write_text(json.dumps(record) + "\n")

    rec = ctx.agent_record()
    env, cli, live = ctx.resolve_env()
    call = record.get("call") or rec.get("call") or live or ""
    if call and live and call != live and not record.get("call"):
        call = live
    is_action = record.get("source") == "action"

    # 1. No ack note: Aux shows the ask in flight in the notebook's status line from this status file (an
    #    action shows on its own note). Notes are for answers and real failures only.
    shown = record.get("label", "") if is_action else text
    short = shown if len(shown) <= 90 else shown[:87].rstrip() + "…"
    acked_at = time.time()
    set_status(d, state="started", ask_id=ask_id, call=call, env=env, text=shown, acked_at=acked_at)

    # 2. The prompt.
    try:
        build_prompt(d, record, env, cli, call)
    except Exception as e:  # the ask still gets answered, with less context
        (d / "prompt.md").write_text(f"# Private ask\n\n## The ask\n{text}\n\n(context unavailable: {e})\n")

    # 3. Wait for a slot, then run the session to completion.
    waited = False
    while running() >= ASK_SLOTS:
        if not waited:
            set_status(d, state="queued")
            waited = True
        time.sleep(2)
    log = open(d / "log", "ab")
    cmd = ["claude", "-p", "--model", a.model, *(["--effort", a.effort] if a.effort else []),
           *ctx.claude_plugin_args(),
           "--permission-mode", "auto", "--max-turns", str(MAX_TURNS),
           "--append-system-prompt", ("You are carrying out one action the operator approved in their notebook, "
                                      if is_action else "You are answering one private ask for the operator, ") +
           "in a short session. Read the prompt file named in the command first; the answer-ask skill says what to do.",
           f"/answer-ask {d}",
           # The notebook's writer, by the path the skill uses and by the script's own.
           "--allowedTools", f"Bash({ctx.AUX / 'bin' / 'aux'}:*)", f"Bash({AUXCLI}:*)"]
    p = subprocess.Popen(cmd, cwd=str(PROJECT) if PROJECT.exists() else None, stdout=log, stderr=subprocess.STDOUT,
                         stdin=subprocess.DEVNULL, start_new_session=True, env={**os.environ, "AUX_CALL": call or "asks"})
    set_status(d, state="running", pid=p.pid, started_at=time.time())
    code = p.wait()
    log.close()
    out = ctx.read(d / "log") or ""
    last = next((l for l in reversed(out.splitlines()) if l.strip()), "no output")
    if is_action:
        # 4a. The note the operator clicked shows how it went. A session that ended without saying is a failure.
        report = action_report(call, record.get("note"), record.get("index"), acked_at)
        if not report:
            aux(call, "action", str(record.get("note")), str(record.get("index")), "failed", "Aux stopped before finishing it")
            report = ("failed", "")
        set_status(d, state="answered" if report[0] == "done" else "failed", ended_at=time.time(), exit=code, answer_title=report[1])
        return 0 if report[0] == "done" else 1
    title = (ctx.read(d / "answer.title") or "").strip() or answered_note(call, acked_at)
    if title:
        set_status(d, state="answered", ended_at=time.time(), exit=code, answer_title=title)
        return 0
    # 4b. A dead session is never silence.
    aux(call, "note", f"Couldn't answer: {short}", f"The session exited {code} without an answer.", "--see", f"{d}/log", "--see", last[:160])
    set_status(d, state="failed", ended_at=time.time(), exit=code)
    return 1


if __name__ == "__main__":
    sys.exit(main())
